授人以 Fortran 得 Fortran,
授人以 Lisp 得所喜之語言。— 蓋伊·史提爾二世《The Seasoned Schemer》
LISP 程式語言家族的編寫方式與編譯器內部使用的語法樹相似,這種特色被稱爲同像性 (Homoiconicity)。依據這項特色,產生了有別於其他語言的魔法,可以編寫程式來改變程式,不僅可以創造自己的語言,還可以擴充程式語言,彌補程式語言的不足。
這個神奇的魔法就是巨集 (Macro),透過這篇文章你將學會如何撰寫巨集。
Clojure 的資料結構之一:列表,與 Clojure 程式非常像。我們可以透過 list
創建列表
(list '+ 1 2)
;; => (+ 1 2)
(class (list '+ 1 2))
;; => clojure.lang.PersistentList
範例中的運算式產生了一個列表,內容爲符號 +
以及 1 與 2。與運算式 (+ 1 2)
看起來一模一樣,差別是使用 list
函式產生的資料。
前面章節介紹過的讀取器 (Reader) 就是將文字轉換成列表後,再做進一步的處理。我們可以使用 read-string
模擬讀取器,將文字轉換成列表:
(read-string "(+ 1 2)")
;; => (+ 1 2)
(class (read-string "(+ 1 2)"))
;; => clojure.lang.PersistentList
read-string
函式讀取文字,轉換成列表。透過 eval
函式,可以把列表當作程式執行,求得執行後的結果:
(eval (list + 1 2))
;; => 3
也可以使用單引號 (') 於列表之前,Clojure 會將列表原封不動返回:
'(+ 1 2)
;; => (+ 1 2)
(eval '(+ 1 2))
;; => 3
單引號 (') 被稱爲引用 (Quote),它之後的列表會被 Clojure 忽略而照實返回,如果將它交給 eval
函式則會被求值。列表若是沒有在前面加上單引號,Clojure 便會將它視爲函式呼叫,呼叫第一個符號代表的函式,如果找不到函式便會報錯:
(a 1 2)
;; => CompilerException java.lang.RuntimeException: Unable to resolve symbol: a in this context
'(a 1 2)
;; => (a 1 2)
(eval '(a 1 2))
;; => CompilerException java.lang.RuntimeException: Unable to resolve symbol: a in this context
使用引用 (Quote) 讓 Clojure 不對列表求值,將程式變成資料,再丟給 eval
函式把資料當作可執行的函式,執行之後取得返回值。
於是藉由列表與引用,可以產生程式的範本,其中包含了之後將被執行的程式碼,這些範本只是一般的資料,可以任意地拼接或修改成任意列表,最終再丟給 eval
或編譯器編譯後執行:
(cons '+ '(1 2))
;; => (+ 1 2)
(eval (cons '+ '(1 2 3)))
;; => 6
現在你應該更了解在 Clojure 中,程式可以是資料,資料也可以是程式的意思了。前面範例使用 eval
則讓我們了解 Clojure 如何將文字變成列表,再將列表變成程式執行的概念模型。
現在可以依據需要,產生列表給 eval
執行後取得結果,現在讓我們把產生列表的功能寫成函式,以便重複使用。
(defn report [form]
(eval (cons 'println (list "report form:" form))))
(report (= 2 (+ 2 3)))
;; => report form: false
原本我們預期的結果應該是:report form: (= 2 (+ 2 3))
,結果卻不如預期,這是爲什麼呢?
原因在於 Clojure 中 (大部分程式語言也是一樣),在呼叫函式之前,程式語言會先把交給函式的參數計算求值完畢,函式再根據參數計算。
因此在我們的範例中 (= 2 (+ 2 3))
便會先求值 (結果爲 false),結果便與我們想要的不一樣。因此如果要達到如此的效果,就要使用巨集。
另外,eval
函式雖然可以幫忙我們將列表轉成程式後求值,但是它無法處理當呼叫 eval
函式時的詞法語境 (Lexical scope),亦即當前可以使用的環境 (繫結或符號等等)。因此使用範圍非常侷限。
從以上範例我們使用 eval
模擬了編譯器將程式求值的行爲,我們也使用了 read-string
模擬了將文字轉換成列表的行爲,也了解到修改列表就可以改變程式的行爲。
而巨集便是可以修改程式、改變行爲的工具。
建立函式使用 defn
或是 fn
,而建立巨集則使用 defmacro
。defmacro
的參數不會預先求值,會返回列表給呼叫巨集者,編譯器會轉成實際程式執行之:
(defmacro infix-add [form]
(list (second form) (first form) (last form)))
(infix-add (2 + 3))
;; => 5
以上範例利用巨集來讀取以中置表示法寫成的形式,並求值之。我們可以使用 macroexpand
觀察巨集如何展開:
(macroexpand '(infix-add (2 + 3)))
;; => (+ 2 3)
反引號 (`) 被稱爲語法引用 (Syntax quote),功能與單引號 (') 類似,差別在於反引號之後的符號會被改成加上命名空間後的全名 (Qualified name),避免衝突。
(def foo "foo")
;; => #'user/foo
'foo
;; => foo
`foo
;; => user/foo
以上範例分別使用引用以及語法引用,引用只返回符號,語法引用則會將符號加上命名空間。建議編寫巨集時使用語法引用,避免衝突問題,而且語法引用也可以與之後介紹的解引用搭配使用。
另一個語法引用 (~) 與引用 (') 的差別,在於語法引用中可以使用波浪號 (~),波浪號稱爲解引用 (Unquote)。將波浪號加在語法引用中的其中一個列表或符號之前,將會使這個符號或列表跳出引用環境,讓編譯器對符號或列表求值,再將它放回引用環境中,產出新的列表。
以下範例示範加上解引用之前與之後的差別:
`(+ 1 (* 2 3))
;; => (clojure.core/+ 1 (clojure.core/* 2 3))
`(+ 1 ~(* 2 3))
;; => (clojure.core/+ 1 6)
第一個範例中只在列表前加上語法引用,於是列表中的符號加上命名空間後,便返回了。而第二個範例中將解引用加在 (* 2 3)
之前,此運算式就會先被求值,再放回語法引用建立的列表之中。
現在我們把之前提過的 report
範例用巨集與語法引用改寫:
(defmacro report [form]
`(println "report form:" '~form))
(report (= 2 (+ 2 3)))
;; => report form: (= 2 (+ 2 3))
看起來一切正常,但是出現了先前沒有出現的符號,究竟是什麼意思呢?先讓我們利用 REPL 看看它到底做了什麼事:
(def a 4)
`(1 2 3 '~a)
;; => (1 2 3 (quote 4))
它先對 a 求值,結果爲 4,再把它套用到 quote
之中。因此在我們的 report
範例中,'~
符號會對 form
求值,form 的值爲 (= 2 (+ 2 3))
,再使用 quote
抑制函式呼叫後放回語法引用產生的範本中。
再擴充 report
巨集,讓它除了可以顯示原始的形式之外,還可以對這個形式求值。因此會再次使用到解引用,以求得形式的值:
(defmacro report [form]
`(println "report form:" '~form ", result:" ~form))
(report (= 3 (+ 2 1)))
;; => report form: (= 3 (+ 2 1)) , result: true
除了解引用之外,還有一種特殊的解引用稱爲解引用拼接 (Unquote-splicing),使用 ~@
符號來達到此功能。
解引用拼接 (Unquote-splicing) 會將之後的列表解開,再放入其他的列表之中:
(def a '(5 6 7 8))
`(1 2 3 4 ~a 9 10)
;; => (1 2 3 4 (5 6 7 8) 9 10)
`(1 2 3 4 ~@a 9 10)
;; => (1 2 3 4 5 6 7 8 9 10)
第一個範例中使用解引用,結果是列表中又有列表,而第二個範例用了解引用拼接之後,解消了原先的列表,將列表中的每個元素放入新的列表之中。
解引用拼接通常用在巨集接受不定個數的運算式,以下範例示範其使用方法:
(defmacro foo [& body]
`(do-something ~@body))
(macroexpand-1 '(foo (do (println "Hello foo") 42)))
;; => (user/do-something (do (println "Hello foo") 42))
由於巨集中已經使用了列表,所以需要先將參數套用解引用拼接,再放入列表中。範例中使用的 macroexpand-1
功能與先前提及的 macroexpand
一樣,都是具有展開巨集的功能,而 macroexpand
展開的巨集之中若還有使用到其他巨集,則會繼續展開。
由於巨集具有範本化程式的功能,在巨集之中若有資料繫結,一不注意就會發生錯誤:
(defmacro bad-macro [& body]
`(let [x :value]
~@body))
(bad-macro (println "bad macro"))
;; => CompilerException java.lang.RuntimeException: Can't let qualified name: user/x
(macroexpand-1 '(bad-macro (println "bad macro")))
;; => (clojure.core/let [user/x :value] (println "bad macro"))
以上巨集範例中使用到資料繫結,被編譯器發現錯誤,因爲沒有使用暫時的名字,而使用全名。若是使用該巨集的環境中已經有相同名稱的資料繫結,就有發生錯誤的可能。
Clojure 提供了 gensym
函式產生編造過的符號名稱,避免與其他符號名稱衝突的可能,以下是使用 gensym
修改過的版本:
(defmacro good-macro [& body]
(let [x (gensym)]
`(let [~x :value]
~@body)))
(good-macro (println "good macro"))
;; => good macro
(macroexpand-1 '(good-macro (println "good macro")))
;; => (clojure.core/let [G__10556 :value] (println "good macro"))
你也可以在語法引用之中,於符號之後加上井字號,功能與使用 gensym
相同:
(defmacro hygienic-macro [& body]
`(let [x# :value]
~@body))
(hygienic-macro "hygienic macro")
;; => "hygienic macro"
(macroexpand-1 '(hygienic-macro "hygienic macro"))
;; => (clojure.core/let [x__10558__auto__ :value] "hygienic macro")
現在你手上已經有了建構巨集的工具,然而究竟巨集可以做到哪些事,以下提出幾項範例,希望給讀者一些靈感。
Ruby 或 Perl 程式語言提供了與 if
相反的控制流程:unless
,只有 unless
中的判斷式爲否,才會執行第一個分支,否則不執行:
(unless (= 1 2) "Math rules !")
;; => "Math rules !"
unless
可以利用 if
達成相同的行爲:
(if (not conditional) then)
因此 unless
巨集如下:
(defmacro unless [conditional & body]
`(if (not ~conditional)
(do ~@body)))
以上巨集使用語法引用建立範本,使用解引用對條件式求值再放回列表,再使用解引用拼接處理巨集剩餘的參數。
你是否曾經在程式中開啓檔案,卻在結束時忘記將檔案關閉,導致記憶體資源浪費?建立一個在運算式最後自動關閉資源的巨集,是非常方便的:
(with-open [r (clojure.java.io/input-stream "tmpfile.txt")]
(println "Do things with opened resource"))
範例如下:
(defmacro with-my-open [bindings & body]
`(let ~(subvec bindings 0 2)
(try
~@body
(finally
(. ~(bindings 0) close)))))
以上範例爲簡易版,Clojure 內建有 with-open
巨集,考慮更周全,請使用內建版本。
由於巨集可以修改語法、改變列表結構,所以巨集的能力只受限於使用者的想像力。但是越是強大的工具,越要謹慎使用。以下有幾點建議:
可以用函式不要用巨集
巨集除錯困難,可以用函式就不需要巨集,除非需要使用到延遲求值功能或建立自己的語法。
使用 macroexpand
或 macroexpand-1
以及 clojure.walk/macroexpand-all
除錯
一旦巨集不如預期運作,使用 macroexpand
相關函式將巨集展開,觀察展開過的列表究竟何處發生問題。
參考別人的巨集
要寫好程式除了了解語言的特性與語法之外,研讀別人的程式碼也是進步的方式之一。Clojure 內建許多巨集,可以在 REPL 中使用 source
函式列出巨集的原始碼,學習其中的思考方式。
透過本篇文章,你知道了 Clojure 中的運算式都是由列表構成,還知道了在編譯之前修改列表,就可以改變編譯的結果。了解到藉由定義巨集可以達成自定語法,也了解了建構巨集的相關工具,還知道了巨集可以如何運用,最後你知道了撰寫巨集應該注意的地方。
還不賴吧?今天就先到這裡,下一篇文章再見囉!
(本篇文章同步刊登於 GitHub,歡迎在文章下方留言或發送 PR 給予建議與指教)